feat: add support for headers and footers#81
Conversation
Process placeholders, conditionals, and loops in document headers and footers using the same visitor pipeline as the body. Also extends template validation and field detection to cover header/footer parts.
Update README, Examples, ARCHITECTURE, REFACTORING, TODO, CLAUDE.md, CHANGELOG, mkdocs, and developer docs to reflect the new header/footer processing feature. Add new docs page for template authors and additional integration tests for nested properties, formatting preservation, and markdown in headers/footers.
- Extract GetHeaderPart/GetFooterPart helpers in DocumentVerifier - Use lazy IEnumerable in HasFields to short-circuit on first match - Extract shared GetHeaderFooterElements iterator in TemplateValidator
…tion - DocumentBuilder: extract AddHeaderPart/AddFooterPart/AppendParagraphs helpers - DocumentVerifier: extract generic GetHeaderFooterPart<TReference> helper - TemplateValidator: delegate FindAllPlaceholders to FindAllPlaceholdersInElements
There was a problem hiding this comment.
Pull request overview
Adds first-class processing and validation of template constructs inside Word header/footer parts, extending the existing visitor pipeline beyond the document body (closes #15).
Changes:
- Extend
DocumentWalker+DocumentTemplateProcessorto traverse and process header/footer parts with the same visitors (placeholders/conditionals/loops). - Extend
TemplateValidatorand field detection to account for headers/footers. - Add integration tests and supporting test helpers, plus documentation updates describing header/footer support.
Reviewed changes
Copilot reviewed 16 out of 16 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| mkdocs.yml | Adds new docs page to site navigation. |
| docs/for-template-authors/headers-footers.md | New guide for header/footer template behavior and examples. |
| docs/for-developers/quick-start.md | Documents that ProcessTemplate processes headers/footers automatically. |
| TriasDev.Templify/Visitors/DocumentWalker.cs | Adds WalkHeadersAndFooters traversal using existing WalkElements pipeline. |
| TriasDev.Templify/Core/DocumentTemplateProcessor.cs | Processes headers/footers during template processing; expands HasFields() to include header/footer parts. |
| TriasDev.Templify/Core/TemplateValidator.cs | Validates header/footer parts and checks missing variables for them. |
| TriasDev.Templify.Tests/Integration/HeaderFooterTests.cs | Adds integration coverage for header/footer placeholders, conditionals, loops, formatting, and multiple types. |
| TriasDev.Templify.Tests/Helpers/DocumentVerifier.cs | Adds header/footer text + formatting extraction helpers for tests. |
| TriasDev.Templify.Tests/Helpers/DocumentBuilder.cs | Adds helpers to create headers/footers in generated test documents. |
| TriasDev.Templify/TODO.md | Marks header/footer support as completed. |
| TriasDev.Templify/REFACTORING.md | Updates refactoring notes to reflect implemented header/footer support. |
| TriasDev.Templify/README.md | Updates feature list and supported locations to include headers/footers. |
| TriasDev.Templify/Examples.md | Adds a header/footer examples section and updates troubleshooting text. |
| TriasDev.Templify/ARCHITECTURE.md | Updates architecture notes to reflect header/footer traversal support. |
| CLAUDE.md | Updates developer notes to include header/footer traversal step. |
| CHANGELOG.md | Adds unreleased entry describing header/footer support. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| private DocumentBuilder AddHeaderPart(HeaderFooterValues type, RunProperties? formatting, params string[] texts) | ||
| { | ||
| MainDocumentPart mainPart = _document.MainDocumentPart!; | ||
| HeaderPart headerPart = mainPart.AddNewPart<HeaderPart>(); | ||
| headerPart.Header = new Header(); | ||
| AppendParagraphs(headerPart.Header, formatting, texts); | ||
| headerPart.Header.Save(); | ||
|
|
||
| string partId = mainPart.GetIdOfPart(headerPart); | ||
| EnsureSectionProperties().Append(new HeaderReference { Type = type, Id = partId }); | ||
| return this; | ||
| } | ||
|
|
||
| private DocumentBuilder AddFooterPart(HeaderFooterValues type, RunProperties? formatting, params string[] texts) | ||
| { | ||
| MainDocumentPart mainPart = _document.MainDocumentPart!; | ||
| FooterPart footerPart = mainPart.AddNewPart<FooterPart>(); | ||
| footerPart.Footer = new Footer(); | ||
| AppendParagraphs(footerPart.Footer, formatting, texts); | ||
| footerPart.Footer.Save(); | ||
|
|
||
| string partId = mainPart.GetIdOfPart(footerPart); | ||
| EnsureSectionProperties().Append(new FooterReference { Type = type, Id = partId }); | ||
| return this; | ||
| } | ||
|
|
||
| private static void AppendParagraphs(OpenXmlCompositeElement container, RunProperties? formatting, string[] texts) | ||
| { | ||
| foreach (string text in texts) | ||
| { | ||
| Paragraph paragraph = new Paragraph(); | ||
| Run run = new Run(); | ||
| Text textElement = new Text(text) { Space = SpaceProcessingModeValues.Preserve }; | ||
| run.Append(textElement); | ||
| if (formatting != null) | ||
| { | ||
| run.RunProperties = (RunProperties)formatting.CloneNode(true); | ||
| } | ||
|
|
||
| paragraph.Append(run); | ||
| container.Append(paragraph); | ||
| } | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Ensures the body has SectionProperties and returns it. | ||
| /// </summary> | ||
| private SectionProperties EnsureSectionProperties() | ||
| { | ||
| SectionProperties? sectionProps = _body.Elements<SectionProperties>().FirstOrDefault(); | ||
| if (sectionProps == null) | ||
| { | ||
| sectionProps = new SectionProperties(); | ||
| _body.Append(sectionProps); | ||
| } | ||
|
|
||
| return sectionProps; | ||
| } |
There was a problem hiding this comment.
AddHeaderPart/AddFooterPart append SectionProperties to the body when it doesn’t exist. Since AddParagraph (and other content builders) always use _body.Append(...), any content added after calling AddHeader/AddFooter will be appended after SectionProperties, producing an invalid WordprocessingML body order (sectPr must be last). Consider inserting new paragraphs/tables before an existing SectionProperties, or moving SectionProperties to the end before saving.
| private DocumentBuilder AddHeaderPart(HeaderFooterValues type, RunProperties? formatting, params string[] texts) | ||
| { | ||
| MainDocumentPart mainPart = _document.MainDocumentPart!; | ||
| HeaderPart headerPart = mainPart.AddNewPart<HeaderPart>(); | ||
| headerPart.Header = new Header(); | ||
| AppendParagraphs(headerPart.Header, formatting, texts); | ||
| headerPart.Header.Save(); | ||
|
|
||
| string partId = mainPart.GetIdOfPart(headerPart); | ||
| EnsureSectionProperties().Append(new HeaderReference { Type = type, Id = partId }); | ||
| return this; | ||
| } | ||
|
|
||
| private DocumentBuilder AddFooterPart(HeaderFooterValues type, RunProperties? formatting, params string[] texts) | ||
| { | ||
| MainDocumentPart mainPart = _document.MainDocumentPart!; | ||
| FooterPart footerPart = mainPart.AddNewPart<FooterPart>(); | ||
| footerPart.Footer = new Footer(); | ||
| AppendParagraphs(footerPart.Footer, formatting, texts); | ||
| footerPart.Footer.Save(); | ||
|
|
||
| string partId = mainPart.GetIdOfPart(footerPart); | ||
| EnsureSectionProperties().Append(new FooterReference { Type = type, Id = partId }); | ||
| return this; |
There was a problem hiding this comment.
AddHeaderPart/AddFooterPart always Append a new HeaderReference/FooterReference for the given type. If a caller adds the same header/footer type twice, the document ends up with multiple references of the same type, and DocumentVerifier currently reads the first one. Consider removing/replacing any existing reference of the same Type before appending the new one.
| TReference? reference = sectionProps.Elements<TReference>() | ||
| .FirstOrDefault(r => r.Type?.Value == type); | ||
|
|
||
| if (reference?.Id?.Value == null) | ||
| { | ||
| throw new InvalidOperationException($"{partName} reference of type {type} not found"); | ||
| } |
There was a problem hiding this comment.
GetHeaderFooterPart only matches references where r.Type?.Value == type. In real Word docs, the default header/footer reference often omits the w:type attribute (null implies Default). As written, GetHeaderText()/GetFooterText() can throw for valid documents. Consider treating Type == null as HeaderFooterValues.Default when type is Default (and/or falling back to a reference with null type).
| private static void ValidateHeadersAndFooters( | ||
| WordprocessingDocument document, | ||
| HashSet<string> allPlaceholders, | ||
| List<ValidationError> errors) | ||
| { | ||
| foreach (List<OpenXmlElement> elements in GetHeaderFooterElements(document)) | ||
| { | ||
| _ = ValidateConditionals(elements, allPlaceholders, errors); | ||
| ValidateLoops(elements, allPlaceholders, errors); | ||
| FindAllPlaceholdersInElements(elements, allPlaceholders); | ||
| } |
There was a problem hiding this comment.
ValidateHeadersAndFooters validates conditionals/loops and collects placeholders, but it doesn’t validate table-row loops (LoopDetector.DetectTableRowLoops) inside header/footer tables. Since DocumentWalker supports table row loops when walking headers/footers, templates with {{#foreach}} row markers in header/footer tables could pass validation incorrectly. Consider extending header/footer validation to also run table-row loop validation for any Table elements in these parts.
| | Type | Description | | ||
| |------|-------------| | ||
| | **Default** | Standard header/footer for most pages | | ||
| | **First Page** | Header/footer shown only on the first page | | ||
| | **Even Page** | Header/footer for even-numbered pages | | ||
|
|
There was a problem hiding this comment.
The Markdown table under "Supported Header/Footer Types" uses a double leading pipe (|| ...) on each row, which renders as an extra empty column in most Markdown engines. Use single leading/trailing pipes (| Type | Description |) for a valid 2-column table.
- Move SectionProperties to end of Body in DocumentBuilder.ToStream() - Remove duplicate header/footer references of same type before adding - Handle null Type as Default in DocumentVerifier reference lookup - Add table-row loop validation for headers/footers in TemplateValidator
…ements to remove duplication
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 16 out of 16 changed files in this pull request and generated 2 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // Remove existing footer reference of the same type to avoid duplicates | ||
| FooterReference? existingFooter = sectionProps.Elements<FooterReference>() | ||
| .FirstOrDefault(r => r.Type?.Value == type); | ||
| existingFooter?.Remove(); | ||
|
|
||
| sectionProps.Append(new FooterReference { Type = type, Id = partId }); | ||
| return this; |
There was a problem hiding this comment.
Same issue for footers: AddFooterPart only removes the first matching FooterReference and won’t remove an existing default footer ref with Type omitted. This can result in duplicate footer references for the default case. Remove all matches and include the (type == Default && r.Type == null) case.
| // Remove existing header reference of the same type to avoid duplicates | ||
| HeaderReference? existing = sectionProps.Elements<HeaderReference>() | ||
| .FirstOrDefault(r => r.Type?.Value == type); | ||
| existing?.Remove(); | ||
|
|
||
| sectionProps.Append(new HeaderReference { Type = type, Id = partId }); | ||
| return this; |
There was a problem hiding this comment.
AddHeaderPart intends to avoid duplicate header references, but it only removes the first match and doesn’t account for existing default header refs where Type is omitted (Word treats missing Type as Default). This can leave multiple default header references in SectionProperties, which can confuse Word and tests that expect a single effective header. Consider removing all matching references, and treat (type == Default && r.Type == null) as a match as well.
AddHeaderPart/AddFooterPart now treat a missing Type attribute as Default when checking for duplicate references, matching the behavior already present in DocumentVerifier.GetHeaderFooterPart.
Summary
DocumentWalker,DocumentTemplateProcessor, andTemplateValidatorto process placeholders, conditionals, and loops in document headers and footers using the existing visitor pipelineHasFields()to detect field codes (TOC, PAGE, etc.) in header/footer partsAddHeader/AddFooterto testDocumentBuilderandGetHeaderText/GetFooterTexttoDocumentVerifierCloses #15
Test plan
dotnet build templify.slncompiles without errorsdotnet test— all 983 tests pass (11 new + 972 existing)dotnet format --verify-no-changes --no-restore— formatting check passes